Skip to content

Traffic replay + playbook schema - #105

Open
Rahul-Ganesan wants to merge 8 commits into
GitM-Labs:mainfrom
Rahul-Ganesan:Parasail-POC
Open

Traffic replay + playbook schema#105
Rahul-Ganesan wants to merge 8 commits into
GitM-Labs:mainfrom
Rahul-Ganesan:Parasail-POC

Conversation

@Rahul-Ganesan

Copy link
Copy Markdown
Contributor

Traffic replay + playbook schema

Two packages that together make a tuning claim checkable by someone who was not in
the room: gitm/traffic/ replays real production traces through a real endpoint
and ties the result back to the workload; gitm/playbook/ holds the row that says
this knob, on this model and GPU, under this traffic, moved these numbers — and
decides when that row may be applied to live traffic.

CPU-only except for firing a replay. No new dependencies. One existing extra
moves: vllm>=0.6vllm>=0.23.0.

PARASAIL_POC.md is the reader's entry point. This body is what changed and why.

What is measured, and what is not

Stated first because the distinction is load-bearing.

state
Adapters, on real published bytes ✅ measured
Replay fidelity, source vs the file vLLM reads ✅ every statistic exactly 0.0
Firing at a live endpoint ✅ real vllm bench serve 0.28.0, 40/40, exit 0
Result joined back to its trace ✅ end to end, 4/4 reconciliation checks
Any playbook row being true ❌ every shipped row is illustrative, deltas invented
The regime-distance threshold uncalibrated, and the code says so at runtime
Pacing under a saturating server ❌ needs a GPU

Nothing here claims a performance win. It is the machinery that would make one
checkable.

gitm/traffic/ — replay

piece what it does
schema.py CanonicalRequest / TraceMeta / DropReason. Every field carries type, units, and what happens when a source lacks it. A trace with no TraceMeta cannot be replayed
adapters.py BurstGPT (all published layouts, _1/_2 and _3) and Mooncake. Read by column name, so a future _4 loads rather than raises
regime.py The axes + label(). source_kind keeps a scoreboard workload from ever reading as production traffic
replay.py Emits vLLM's native timed_trace format. No custom load generator
parameterize.py Fit the envelope, sample the grid, mark every out-of-envelope point /xenv
validate.py Replayed vs source: arrival timing, both length distributions, rate, dispersion — rendered, with any mismatch explained in prose
runner.py Fires the argv, version guard first
results.py Joins bench serve's output back to the workload that produced it

The three traces, measured not assumed

trace rows input p50 / p95 D label
BurstGPT_1 383/400 353 / 1,638 1.01 prod/io1/in256/out128/burst-poisson/copen
BurstGPT_3 399/400 309 / 1,497 1.01 prod/io2/in256/out64/burst-poisson/copen
Mooncake 400/400 9,075 / 49,904 6.74 prod/io32/in8k/out256/burst-hi/copen

D 6.74 vs 1.01, input p50 9,075 vs 353 — the two axes that matter separate
cleanly on real data. check_regime_axes_separate_the_traces asserts it, because
axes that cannot tell two production traces apart are decoration.

BurstGPT_3 inserts Session ID and Elapsed time at positions 1 and 2, not
appended — a positional reader takes Session ID as the model. On 5,643 real v3
rows, session id is populated on exactly the Conversation log rows and empty
on exactly the API log rows: 90% of a real v3 trace is single-shot traffic
with no conversation
, so an empty one is emitted as session_id=None rather than
dropped. Dropping would have discarded 5,115 of 5,643 rows.

Real data handling, enforced rather than logged

7 named drop reasons, each firing exactly once against both a CSV and a JSONL
dirty fixture. Counts land in TraceMeta, and a trace whose counts do not
reconcile cannot be constructed
rows_read == rows_emitted + dropped in
__post_init__. A caller's own filter counts separately from bad data and still
reconciles.

The zero-token rows are real and are the majority defect: 744 of 9,382
Conversation log rows carry 0/0
; all 19 API log rows are well-formed. A
property of the log, not a parse artefact.

Fidelity, against the file vLLM reads

Not against an in-memory copy — the emitter writes it, read_timed_trace
re-parses it, compare puts the two side by side:

request_count 0 · arrival_ks 0 · input_len_ks 0 · output_len_ks 0 · rate_rps 0 · burstiness 0
PASS — the pipeline preserves the trace

Every statistic exactly 0.0, on both adapters.

Fired end to end against real vLLM 0.28.0: 40/40 completed, 12.008 s against a
12.000 s trace span
, confirmed by two independent clocks — vLLM's own and the
receiving server's 11.996 s across 40 POSTs.

The silent failure this exists to catch

vLLM's --timed-trace-chunk-hash-size defaults to 16; Mooncake's blocks are
512. At the default every prompt is 32× short while completed,
duration, throughput and every percentile still read perfectly. Block size
verified rather than assumed: over 1,546 Mooncake rows, max(input_length / (len(hash_ids)·512)) = 1.0 exactly — the blocks tile each prompt with no slack.

The emitter refuses to write such a file; the joiner catches one fired from a plan
built elsewhere. On the real run total_input_tokens came back 506,280 against
506,280
— at 16-token blocks it would have been ~15,821.

Two result fields are wrong, not just missing

--save-result writes 34 keys. Under --self-timed vLLM still records the CLI's
request_rate ("inf" — a string, since json.dumps cannot write bare
Infinity) and burstiness (1.0), against the trace's real 2.837 rps and D
6.74. Exactly the two axes a playbook row keys on.

Dropped with a stated reason, values kept visible, raw JSON kept whole. A new
vLLM field cannot fall out silently: unjoined_keys() fails on any key neither
kept nor deliberately dropped — it caught rtfx on its first run.

gitm/playbook/ — the row

match.py is separate from schema.py because the match semantics, not the field
list, is the part that gets argued about.

The identity split

Exact on model+revision, GPU SKU, engine+version, source_kind, concurrency
and the knob key set. Distance on the numeric regime axes, because live
traffic never lands on a measured point. Each gate is a named MatchPolicy field,
so loosening one is visible in a diff rather than an accident inside a comparison.

source_kind is asserted with every numeric axis identical, so only the gate
can be doing the work.

|log2(a/b)|, combined with L-infinity

Scale-free, symmetric, zero at equality, inf when one side is zero. Burstiness
uses |log2((1+a)/(1+b))| because D = 0 is a real trace and a bare ratio would
make a perfectly paced trace incomparable to everything, including another paced
one.

Six axes. The p95s are in because the p50s alone cannot separate a
long-context workload from a short one with a heavy tail — which is exactly where
prefix caching and chunked prefill live.

L-inf, not a mean, asserted on the case it exists for: identical on five axes,
8× off on input_p95 reads 0.5 as a mean and 3.0 as L-inf. The mean
applies the row. For scale the two real traces are 4.929 apart, limited by
input_p95
.

rate_rps is deliberately not an axis, and the exclusion is material rather
than an omission: two regimes identical but for an 8× rate difference are 0.0
apart by default and 3.0 with the axis on. The inclusion rule and the
experiment that would settle it are written down.

No threshold ships

AxisTolerance(max_distance=1.0)                                              # ValidationError
AxisTolerance(max_distance=1.0, calibration="prereg E4: sign flip at 1.4")   # ok

An exact regime match still returns a row, so the schema is usable today; any
nonzero distance returns UNCALIBRATED, names the limiting axis, and routes
to discovery. A placeholder cannot quietly become a production constant. The
four-step calibration procedure is in the module, so the open state has an exit.

Lifecycle

A row is refused at construction without provenance: no knobs, repeats < 2, a
delta missing its latency percentiles, an empty trace_sha256. MeasuredDelta
carries throughput and TTFT/ITL percentiles — the schema cannot express a
throughput-only row. Invalidation is a field with a reason, never a deletion.

evidence ∈ {measured, illustrative} exists because the worked examples ship in
the same format as real rows; without it an example copied into a live playbook is
indistinguishable from a promoted one.

row_from_runs builds a row from two joined runs — a BenchRun is one arm, a
row is the difference — refusing arms that did not run the same trace, any run
that did not reconcile, unequal repeat counts, and a missing latency percentile.
Medians, never means.

delta_is_floor

BurstGPT has no prefix identity, so a replay synthesizes unique blocks per
request: lengths hold, and no sharing is invented that the source never had. A
prefix-cache knob measured there saw the least reuse the real traffic could
have had, so its delta is a lower bound — usable as "at least this much",
never quotable as the gain. A computed property, not a stored flag.

ex6 is that case; ex2 is the control — same synthesized trace, a knob that does
not depend on reuse, so not a floor.

The examples ship nothing selectable

6 rows, 0 selectable. Regimes real, deltas invented, every row labelled. A
perfectly matching query returns nothing and names the reason per row. The
largest claimed delta in the file (+22%) is the scoreboard row — the one most
likely to be copied, gated out by equality rather than distance. Regenerated by
make_examples.py and byte-identical on re-run.

Verification

python -m gitm.traffic  --selftest    24 checks, 3 real traces, 7 drop reasons
python -m gitm.playbook --selftest    19 checks, 2 real regimes, 0 calibrated axes
pytest tests/test_traffic.py tests/test_playbook.py -q          45 passed
ruff check gitm/traffic gitm/playbook                  All checks passed!

Assertions live once and run from both entry points, so the check a reader is told
about and the check CI runs are the same check.
test_every_check_is_registered fails if a check_* function is defined and left
out of the list.

Six fixtures pinned by sha256 through the existing gitm.bench.manifest
(gitm.bench.manifest/v1), plus the real bench serve result JSON — 7 files,
150,941 bytes, verify_manifest clean. Both trace fixtures are unmodified
prefixes of published files, with their source URLs recorded.

Full suite: 11 failures, all pre-existing — module-for-module identical to the
list on clean b2da5b6, all missing optional deps or importer goldens. None in
gitm/traffic or gitm/playbook.

Two bugs found by running it rather than reading it

  • runner.py executed a bare vllm from PATH, which fails under an
    absolute interpreter (a conda env used without activation — normal in CI and
    WSL) and, worse, could have validated one install and run another:
    check_vllm() reads importlib.metadata for this interpreter.
    vllm_executable() now derives the binary from sys.executable.
  • --tokenizer was missing from the argv builder. bench serve builds a
    tokenizer from --model even though timed_trace sends pre-tokenized prompts,
    so any served name HuggingFace cannot resolve dies in
    AutoTokenizer.from_pretrained — long after the endpoint answered. Bites a stub
    server and any --served-model-name rename.

The one dependency change

vllm = ["vllm>=0.6"]vllm = ["vllm>=0.23.0"], with the reasoning in the
file. bench serve --dataset-name timed_trace landed in bfb9ebc21 (2026-05-28),
one day after v0.22.0 shipped — established by checking the tags (v0.22.0 ❌,
v0.22.1 ❌, v0.23.0 ✅), not inferred from a changelog. Below the floor the run
dies on an argparse complaint about an unknown dataset name, which reads like a
typo in our command rather than a missing feature.

Not split into a separate [traffic] extra: the library is CPU-only except
for firing, so an extra named for it would claim you need a multi-gigabyte CUDA
install to use something that does not need one. The >=0.19.0 "conflict" was
prose in three install commands, never a packaging constraint.

Also included

gitm/_banner.py (56 lines, stdlib) and its six-line wiring into gitm/cli.py.
It is a dependency of both new CLIs. stderr, and only when sys.stdout.isatty()
— the gate asks about stdout even though the banner goes to stderr, because the
question is "is a human watching" and stdout is where the answer is.
check_banner_can_never_corrupt_stdout asserts the non-TTY case is silent and
stdout stays empty. The failure it guards is remote from its cause: a banner on
stdout surfaces as a JSON parse error in a CI step, nowhere near the banner.

Rahul-Ganesan and others added 8 commits September 3, 2026 19:17
…e workload

Adds `gitm/traffic/` — canonical request schema, two adapters, a regime tagger,
replay through vLLM's native `bench serve`, and the join that ties a result back
to the trace that produced it.

No custom load generator. Replay goes through `--dataset-name timed_trace
--self-timed`, which schedules every request at its own timestamp. The `[vllm]`
extra floors at 0.23.0 because that dataset landed in bfb9ebc21 (2026-05-28), one
day after v0.22.0 shipped — checked at the tags, not inferred from a changelog.
Below the floor the run dies on an argparse complaint about an unknown dataset
name, which reads like a typo in our command rather than a missing feature.

Three real traces, pinned by sha256 through the existing gitm.bench.manifest.
BurstGPT_1 383/400 rows, in p50/p95 353/1638, D=1.01. BurstGPT_3 399/400,
309/1497 — its `Session ID` and `Elapsed time` are inserted at positions 1 and 2,
not appended, so a positional reader takes the session id as the model; columns
are read by name. Mooncake 400/400, 9075/49904, D=6.74. The two axes that matter
separate cleanly on real data, which is what makes them axes rather than
decoration.

Real data handling is enforced, not logged: 7 named drop reasons, each firing
exactly once against a CSV and a JSONL dirty fixture, and a trace whose counts do
not reconcile cannot be constructed. A caller's own filter counts apart from bad
data and still reconciles.

Fidelity is checked against the file vLLM will read, not an in-memory copy —
every statistic exactly 0.0 on both adapters. Fired end to end against real
`vllm bench serve` 0.28.0: 40/40 completed, paced to 12.008s against a 12.000s
trace span, confirmed by two independent clocks.

The failure this exists to catch: `--timed-trace-chunk-hash-size` defaults to 16
against Mooncake's 512-token blocks, making every prompt 32x short while
completed, duration, throughput and every percentile still read perfectly. Block
size verified over 1,546 rows rather than assumed. The emitter refuses to write
such a file; the joiner catches one fired from a plan built elsewhere.

Two result fields are wrong rather than missing: under `--self-timed` vLLM still
records the CLI's `request_rate` ("inf", a string) and `burstiness` (1.0) against
the trace's real 2.837 rps and D 6.74 — exactly the two axes a playbook row keys
on. Dropped with a stated reason, values kept visible. `unjoined_keys()` fails on
any new vLLM key neither kept nor deliberately dropped; it caught `rtfx` on its
first run.

Also `gitm/_banner.py` and its wiring into `gitm/cli.py` — a dependency of the new
CLI. stderr, and only when stdout is a TTY: the gate asks about stdout because
the question is whether a human is watching, and a banner on stdout surfaces as a
JSON parse error in a CI step, nowhere near the banner.

24 selftest checks, the same assertions as 25 pytest cases, ruff clean.
Knob and environment fields are left pending until the shared config-capture
schema exists.
Adds `gitm/playbook/` — what a promoted tuning result *is*, and when it may be
applied to live traffic. The contract between detection and the apply runtime, so
it ships as types rather than as a doc two implementations read differently.

`match.py` is separate from `schema.py` because the match semantics, not the field
list, is the part that gets argued about.

Identity is a split. Exact equality on model+revision, GPU SKU, engine+version,
source_kind, concurrency and the knob key set; distance on the numeric regime
axes, because live traffic never lands on a measured point. Each gate is a named
MatchPolicy field, so loosening one is an edit visible in a diff rather than an
accident inside a comparison. The source_kind gate is asserted with every numeric
axis identical, so only the gate can be doing the work — a scoreboard result never
satisfies a production query.

Distance is |log2(a/b)| on the raw axes: scale-free, symmetric, zero at equality,
inf when one side is zero, because "no output tokens" is not a small version of
"some". Burstiness uses the shifted ratio so a perfectly paced trace stays
comparable instead of returning infinity. Six axes — the p95s are in because the
p50s alone cannot separate a long-context workload from a short one with a heavy
tail, which is exactly where prefix caching and chunked prefill live.

Combined with L-infinity, not a mean: a row is as far away as its worst axis.
Asserted on the case it exists for — identical on five axes and 8x off on
input_p95 reads 0.5 as a mean, which applies the row, and 3.0 as L-inf, which does
not. For scale the two real traces are 4.929 apart, limited by input_p95.

rate_rps is deliberately not an axis and the exclusion is material rather than an
omission: two regimes identical but for an 8x rate difference are 0.0 apart by
default and 3.0 with the axis on. The inclusion rule and the experiment that would
settle it are written down.

No distance threshold ships. AxisTolerance refuses a max_distance without the run
that produced it, and the shipped policy carries none. An exact regime match still
returns a row, so the schema is usable today; any nonzero distance returns
UNCALIBRATED, names the limiting axis, and routes to discovery. Calibration needs
the same knob measured across nearby regimes, which needs a GPU — the cost of the
open state is a discovery run, the cost of an invented threshold is a knob applied
to traffic nobody measured it on.

A row cannot be constructed without provenance: no knobs, repeats < 2, a delta
missing its latency percentiles, or an empty trace_sha256 all fail. MeasuredDelta
carries throughput and TTFT/ITL percentiles, so the schema cannot express a
throughput-only row. Invalidation is a field with a reason, never a deletion.
`evidence` separates measured from illustrative, because the worked examples ship
in the same format as real rows.

delta_is_floor falls out of the traces: BurstGPT has no prefix identity, so a
replay synthesizes unique blocks per request and a prefix-cache knob measured
there saw the least reuse the real traffic could have had. Its delta is a lower
bound, never quotable as the gain. ex6 is that case; ex2 is the control.

row_from_runs builds a row from two joined runs — one BenchRun is one arm, a row
is the difference — refusing arms that did not run the same trace, any run that
did not reconcile, unequal repeat counts, and a missing latency percentile.
Medians, never means.

The shipped examples are 6 rows, 0 selectable: regimes real off the pinned
fixtures, deltas invented, every row saying so. A perfectly matching query returns
nothing and names the reason per row. The largest claimed delta in the file is the
scoreboard row, gated out by equality rather than distance.

19 selftest checks, the same assertions as 20 pytest cases, ruff clean.
The entry point for the two packages above. Leads with what is measured and what
is not, because the distinction is load-bearing: the adapters, replay fidelity,
firing at a live endpoint and the result join are measured; no playbook row is
true, the regime-distance threshold is uncalibrated, and pacing under a saturating
server needs a GPU.

Nothing here claims a performance win. It is the machinery that would make one
checkable.
Removed comments regarding version constraints and traffic library usage.
Removed comments related to CUDA and dependencies.
Removed banner argument handling from CLI parser.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants